Skip to content

feat(rate-limit): structured 429 + Retry-After header (#10) - #44

Merged
amrtgaber merged 1 commit into
mainfrom
feat/rate-limit-feedback
May 27, 2026
Merged

feat(rate-limit): structured 429 + Retry-After header (#10)#44
amrtgaber merged 1 commit into
mainfrom
feat/rate-limit-feedback

Conversation

@amrtgaber

Copy link
Copy Markdown
Contributor

Closes #10.

Summary

When the rate-limit middleware refuses a request, the response now carries enough information for the frontend to render a precise countdown instead of a generic "try again later":

  • `Retry-After` header with integer seconds until the next request will succeed (per RFC 7231 §7.1.3).
  • Structured body shape:
    ```json
    {
    "detail": {
    "code": "rate_limited",
    "message": "Too many requests on POST /auth/jwt/login. Try again in 42 seconds.",
    "limit": "5 per 1 minute",
    "retry_after": 42
    }
    }
    ```

The reset timestamp comes from `limits.strategies.MovingWindowRateLimiter.get_window_stats(item, key)`, which after a refused hit reports when the oldest in-window request rolls off — i.e., when capacity will next open up. Clamped to ≥1s so the frontend never gets a zero/negative countdown from edge timing.

Stable contract

  • `detail.code === "rate_limited"` is the discriminator the frontend should match on (not the message text).
  • `retry_after` (body) and `Retry-After` (header) always agree, so the frontend can read either.

Test plan

  • `tests/test_rate_limit_feedback.py` — 3 tests:
    • Hammers `POST /auth/register` (3/min) past its limit; verifies the 429 has the new header and the new body shape.
    • Confirms `message` says `"1 second."` vs `"N seconds."` correctly.
    • Hits `/health` 20× to confirm the middleware is path-scoped and doesn't fire on un-limited endpoints.
  • Full suite: 128 passed.
  • `uv run ruff check . && uv run ruff format --check .` — clean.

Frontend follow-up

Auth-web's existing login / register error handling currently shows a generic error on 429. Once this ships, the frontend can read `detail.retry_after` and render a countdown ("Try again in 0:42") for a much better UX. Not blocking — backwards compatible because old handlers will just show whatever they did for the generic 429 string, and the new error shape doesn't break form-validation flows.

When the rate-limit middleware refuses a request, the response now
carries enough information for the frontend to render a precise
countdown instead of a generic "try again later":

  - ``Retry-After`` header with integer seconds until the next request
    will succeed (per RFC 7231 §7.1.3).
  - Structured body shape
    ``{"detail": {"code, "message", "limit", "retry_after"}}``. The
    ``code`` is a stable ``rate_limited`` string so the frontend can
    branch on it; ``message`` is a human-readable string with the
    correct singular/plural for ``second`` vs ``seconds``; ``limit`` is
    the configured ratio (e.g. ``"5 per 1 minute"``) for diagnostic
    surfacing; ``retry_after`` mirrors the header so callers can use
    either source.

The reset timestamp comes from ``limits.strategies.MovingWindowRateLimiter.
get_window_stats(item, key)``, which after a refused hit reports when
the oldest in-window request rolls off. Clamped to ≥1s so the frontend
never gets a zero/negative countdown from edge timing.
@amrtgaber
amrtgaber merged commit f6e9297 into main May 27, 2026
2 checks passed
@amrtgaber
amrtgaber deleted the feat/rate-limit-feedback branch May 27, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add rate limit feedback in error responses

1 participant